跳到主要内容

Java 并发编程-JMM 概念

前置知识:共享内存并发模型 和 消息传递并发模型区别

并发编程模型的两个关键问题

  • 线程间如何通信?即:线程之间以何种机制来交换信息
  • 线程间如何同步?即:线程以何种机制来控制不同线程间操作发生的相对顺序

有两种并发模型可以解决这两个问题:

  • 消息传递并发模型(其实就是分布式锁那套)
  • 共享内存并发模型

Java 中的并发采用的就是共享内存模型,因为这样 Java 线程之间的整个通信过程对程序员来说是完全透明的。

通信的两种并发模型实现

  • 消息传递并发模型:线程之间没有公共状态,线程之间必须通过发送消息来进行显示通信。
  • 共享内存并发模型:线程之间共享程序的公共状态,通过写-读内存中的公共状态来进行隐式通信。

同步的两种并发模型实现

  • 消息传递并发模型:由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
  • 共享内存并发模型:同步是显示进行的,程序员必须显示指定某个方法或某段代码需要在线程之间互斥执行。

如果线程A 与线程B 之间要通信的话,必须经历下面两个步骤:

1、线程A 把本地内存A 中更新过的共享变量刷新到主内存中去

2、线程B 到主内存中去读取线程A 之前已更新过的共享变量

从以上两个步骤来看,共享内存模型完成了 “隐式通信” 的过程。

什么是 JMM?

参考资料 Java内存模型详解(JMM)

Java 虚拟机规范中定义一种 Java 内存模型(Java Memory Model,JMM)来 屏蔽各个硬件平台和操作系统的内存(RAM)访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以 读/写 共享变量的副本。

本地内存是 JMM的一个抽象概念,其实并不存在,它覆盖了缓存、写缓冲区、寄存器以及其它的硬件和编译器优化

注意:有些地方又叫这个本地内存为工作内存(working memory)

举个简单的例子:在 Java中,执行下面这个语句:

i  = 10;

执行线程必须先在自己的工作线程中对变量 i 所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值 10 写入主存当中。

JMM 与 Java 内存区域划分的区别

上面两小节分别提到了 JMM 和 Java 运行时内存区域的划分,这两者既有差别又有联系:

区别:两者是不同的概念层次。JMM是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而 Java运行时内存的划分是具体的,是 JVM运行 Java程序时,必要的内存划分。

联系:都存在私有数据区域和共享数据区域。一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。

实际上,他们表达的是同一种含义,这里不做区分。

运行时内存的划分

image.png

对于每一个线程来说,栈都是私有的,而堆是共有的。

也就是说在栈中的变量(局部变量、方法定义参数、异常处理器参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,本文称为共享变量。

所以,内存可见性是针对的共享变量。

为什么在堆中会有内存不可见问题

参考资料 第六章 Java内存模型基础知识 参考资料 2020最新Java并发进阶常见面试题总结?id=_21-cpu-缓存模型

Java 线程之间的通信由 Java 内存模型(简称JMM)控制,从抽象的角度来说,JMM 定义了线程和主内存之间的抽象关系。JMM 的抽象示意图如图所示:

image.png

从图中可以看出:

  • 所有的共享变量都存在主内存中。
  • 每个线程都保存了一份该线程使用到的共享变量的副本。

线程之间的共享变量存在主内存中,每个线程都有一个私有的本地内存,存储了该线程以读、写共享变量的副本。本地内存是 Java 内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。

现代计算机为了高效,所以往往会在高速缓存区中缓存共享变量,直接读取 Cache 中缓存共享变量,而不是直接读取内存,因为 CPU 访问缓存区比访问内存要快得多。

CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

如果线程 A 与线程 B 之间要通信的话,必须经历下面2个步骤:

  • 线程A 将本地内存A 中更新过的共享变量刷新到主内存中去。
  • 线程B 到主内存中去读取线程A 之前已经更新过的共享变量。

注意,根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取

所以线程 B 并不是直接去主内存中读取共享变量的值,而是先在本地内存B 中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存 B 去主内存中读取这个共享变量的新值,并拷贝到本地内存B 中,最后线程B 再读取本地内存B 中的新值。

这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。

要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

所以,volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可见性。

如何更新共享变量到 Cache?

JVM 怎么知道这个共享变量的被其他线程更新了呢?这就是 JMM 的功劳了,也是 JMM 存在的必要性之一。JMM 通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。

看下面 “JMM 是如何保证原子性的?” 这一节

TODO: 具体这块的细节以后学习了再补充...

在更底层,JMM 通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员的方便理解,提出了 happens-before,它更加的简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则以及这些规则的具体实现方法。

JMM 是如何处理三大特性

JMM 主要是围绕着原子性,可见性,有序性建立的,那么 Java语言 本身对 原子性、可见性以及有序性提供了哪些保证呢?

JMM 是如何保证原子性的?

在 Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子

x = 10;        //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4

上面的语句中只有语句 1是原子操作,其它的三个都不是原子操作

语句1 是直接将数值 10 赋值给 x,也就是说线程执行这个语句的会直接将数值 10 写入到工作内存中。

语句2 实际上包含 2 个操作,它先要去读取 x 的值,再将 x 的值写入工作内存,虽然读取 x 的值以及 将 x 的值写入工作内存这 2 个操作都是原子性操作,但是合起来就不是原子性操作了。

同样的,x++x = x+1 包括 3 个操作:读取 x 的值,进行加 1 操作,写入新的值。

所以上面 4 个语句只有语句 1 的操作具备原子性。

也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

JMM 定义了 8 种操作来保证每个操作都是原子性的,不可再分的

关键词作用
lock(锁定)作用于主内存的变量,它把一个变量标识为一个线程独占的状态;
unlock(解锁)作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他锁锁定。
read(读取)作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存,以便后续 load 动作的使用。
load(载入)作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本中。
use(使用)作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令时,将会执行这个操作。
assgin(赋值)作用于工作内存的变量,它把一个从执行引擎收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时会执行这个操作。
store(存储)作用于工作内存的变量,它把工作内存中的一个变量的值传递给主内存中,以便后续的 write 操作使用。
write(写入)作用于主内存的变量,它把 store 操作从工作内存中得到的变量值放入主内存的变量中。

执行流程如下

image.png

不过这里有一点需要注意:在 32位平台下,对 64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的 JDK中,JVM 已经保证对 64位数据的读取和赋值也是原子性操作了。

从上面可以看出,Java 内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronizedLock 来实现。由于 synchronizedLock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

volatile 保证的可见性

对于可见性,Java 提供了 volatile 关键字来保证可见性。

当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过 synchronizedLock 也能够保证可见性,synchronizedLock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

volatile 保证的有序性

在 Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

在 Java 里面,可以通过 volatile 关键字来保证一定的 “有序性”。另外也可以通过 synchronizedLock 来保证有序性,很显然,synchronizedLock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

不过说来,Java 内存模型中的重排序也有很多种

1、编译器优化的重排序。编译器在不改变单线程程序语义(输出结果不变)的前提下,可以重新安排语句的执行顺序

2、指令级并行的重排序(处理器重排序)。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

3、内存重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

顺序一致性模型与 JMM 的保证

顺序一致性模型是一个 理论参考模型,内存模型在设计的时候都会以顺序一致性内存模型作为参考。

数据竞争与顺序一致性

当程序未正确同步的时候,就可能存在数据竞争。

数据竞争:在一个线程中写一个变量,在另一个线程读同一个变量,并且写和读没有通过同步来排序。

如果程序中包含了数据竞争,那么运行的结果往往充满了不确定性,比如读发生在了写之前,可能就会读到错误的值;如果一个线程程序能够正确同步,那么就不存在数据竞争。

Java内存模型(JMM)对于正确同步多线程程序的内存一致性做了以下保证:

如果程序是正确同步的,程序的执行将具有顺序一致性。 即程序的执行结果和该程序在顺序一致性模型中执行的结果相同。

这里的同步包括了使用 volatilefinalsynchronized 等关键字来实现多线程下的同步。

同步程序的顺序一致性

在顺序一致性模型中,所有操作完全按照程序的顺序串行执行。但是 JMM中,临界区内(同步块或同步方法中)的代码可以发生重排序(但不允许临界区内的代码“逃逸”到临界区之外,因为会破坏锁的内存语义)。

虽然线程 A 在临界区做了重排序,但是因为锁的特性(线程 B 阻塞),线程 B 无法观察到线程 A 在临界区的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

同时,JMM会在退出临界区和进入临界区做特殊的处理,使得在临界区内程序获得与顺序一致性模型相同的内存视图。

由此可见,JMM的具体实现方针是:在不改变(正确同步的)程序执行结果的前提下,尽量为编译期和处理器的优化打开方便之门。

未同步程序的顺序一致性

对于未同步的多线程程序,JMM 只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。

为了实现这个安全性,JVM 在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象(这两个操作是同步的)。

JMM没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,那么 JMM 需要禁止大量的优化,对程序的执行性能会产生很大的影响。

未同步程序在 JMM 和顺序一致性内存模型中的执行特性有如下差异:

1、顺序一致性保证单线程内的操作会按程序的顺序执行;JMM不保证单线程内的操作会按程序的顺序执行。(因为重排序,但是JMM保证单线程下的重排序不影响执行结果)

2、顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。(因为JMM不保证所有操作立即可见)

3、顺序一致性模型保证对所有的内存读写操作都具有原子性,而JMM不保证对64位的 long 型和 double 型变量的写操作具有原子性(后面的版本有保证)。

happens-before

什么是happens-before?

一方面,程序员需要 JMM 提供一个强的内存模型来编写代码;另一方面,编译器和处理器希望 JMM 对它们的束缚越少越好,这样它们就可以最可能多的做优化来提高性能,希望的是一个弱的内存模型。

JMM 考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。

而对于程序员,JMM 提供了 happens-before 规则(JSR-133规范),满足了程序员的需求——简单易懂,并且提供了足够强的内存可见性保证。换言之,程序员只要遵循 happens-before 规则,那他写的程序就能保证在 JMM 中具有强的内存可见性。

JMM 使用 happens-before 的概念来 定制两个操作之间的执行顺序。这两个操作可以在一个线程以内,也可以是不同的线程之间。因此,JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证。